今天繼續介紹 infinite scroll
功能。
還記得我們 posts
資料是透過 getPosts
拿的,現在我們要來改寫 getPosts
內容讓他變成可以有 paginaction
的 api
。
export default function Home() {
const utils = api.useContext()
const { data: posts, isLoading, isError, error } = api.posts.getPosts.useQuery(undefined, {
trpc: {
context: {
skipBatch: true,
}
}
})
再開始寫 api
前先簡單介紹一下常見的 pagination
種類:
在 prisma
中要實現 Offset pagination
很簡單只要使用 skip()
跟 limit()
。
透過 skip
跳過前幾筆資料,然後搭配 take
拿取資料長度完成範圍的查詢。
const results = await prisma.post.findMany({
skip: 3,
take: 4,
})
如果要實現 pagination
功能只需要將 skip
* 每頁顯示總數就好。
這樣就是第三頁顯示四筆資料結果。
const results = await prisma.post.findMany({
skip: 3 * 4,
take: 4,
})
Offset
pagination 優點user
的 filter
排序順序對應到相同的內容。例如資料根據 title
降冪排序,同時塞選 email
中包含 Prisma
內容,如果是用 Offset pagination
每次的紀錄查詢都會是固定結果。
const results = await prisma.post.findMany({
skip: 200,
take: 20,
where: {
email: {
contains: 'Prisma',
},
},
orderBy: {
title: 'desc',
},
})
Cursor-based pagination
差,假設你要 skip
20000
筆資料,這次的 query 查詢都會遍歷前 20000 的結果。Cursor-based pagination
是基於Offset pagination
的延伸,有點就是書籤的概念透過 cursor
每次查詢添加書籤位置,讓一下次的查詢可以根據 cursor
位子繼續查詢,好處就是可以避免 Offset pagination
中遍歷問題。
當使用 Cursor-based
每次查詢都會返回 lastId
當作你下一次的 cursor
。
const secondQueryResults = await prisma.post.findMany({
take: 4,
skip: 1, // Skip the cursor
cursor: {
id: myCursor,
},
where: {
title: {
contains: 'Prisma' /* Optional filter */,
},
},
orderBy: {
id: 'asc',
},
})
const lastPostInResults = secondQueryResults[3] // Remember: zero-based index! :)
const myCursor = lastPostInResults.id // Example: 52
這樣結果就是 cursor
id 為 29 的紀錄之後的 post 前 4 筆資料。
cursor
去記錄每次的查詢結果,讓SQL 底層不需要使用 offset
一次又一次的遍歷,加快查詢結果。page
跳轉到指定的頁面,假設你要請求 40頁的內容,你需要先請求 1 - 39頁的資料。先定義 input schema
export const getInfinitePostSchema = z.object({
limit: z.number().min(1).max(100).nullable(),
where: z.object({
content: z.string().optional(),
title: z.string().optional()
}).optional(),
cursor: z.number().nullish()
})
cursor
,並 orderBy postId
做降冪排序。input
塞選 posts 是否包含 title
或是 content
結果。take: limit + 1
每次選查詢數量。cursor: cursor ? { id: cursor } : undefined
第一次查詢因為沒有 cursor
所以是 undefinded
。posts.length >= limit
如果查詢結果大於 limit
代表還有往後還有資料,所以就拿取 posts
最後的 id
當作下一次的 cursor
。export const postsRouter = router({
infinitePosts: publicProcedure
.input(getInfinitePostSchema)
.query(async ({ input, ctx }) => {
const { cursor, where } = input
const { prisma } = ctx
const limit = input.limit ?? 50
const posts = await prisma.post.findMany({
where: {
title: {
contains: where?.title
},
content: {
contains: where?.content
},
},
orderBy: {
id: 'desc'
},
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined
})
let nextCursor: number | undefined
if (posts.length >= limit) {
nextCursor = posts.pop()?.id
}
return {
posts,
nextCursor
}
}),
client
部分是透過 useInfiniteQuery
去完成所有事情,主要兩個部分。
limit
: 對應到 infinitePosts
中的 input
,用來定義資料返回數量。
getNextPageParam
:
useInfiniteQuery
拿到資料時會返回最後一次查詢的結果 (lastPage) 對應我們在 infinitePosts
中 return 得內容。export const postsRouter = router({
infinitePosts: publicProcedure
.input(getInfinitePostSchema)
.query(async ({ input, ctx }) => {
//..
return {
posts,
nextCursor
}
}),
2.他會 return
一個結果用於下一次查詢的變數,這邊也就是 input 的cursor
,
export const postsRouter = router({
infinitePosts: publicProcedure
.input(getInfinitePostSchema)
.query(async ({ input, ctx }) => {
const { cursor, where } = input
//..
return {
posts,
nextCursor
}
}),
getNextPageParam
return 是 undefinded,則 hasNextPage
為 false
,fetchNextPage
則不能執行。const { data, isLoading, isError, error, fetchNextPage, hasNextPage } = api.posts.infinitePosts.useInfiniteQuery(
{
limit: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor
}
)
這邊第一次看到這樣寫法可能很多小夥伴對於 input
來源感到疑惑,但這邊簡單說明,在 trpc
中預設是透過 getNextPageParam
傳 cursor
到 input
中,其餘的 input
則是透過 useInfiniteQuery
的第一個參數傳進去。
從 response
結果來看 data
是在很深層的位子。
所以透過 flatMap
的方式把我們需要的資料來出來。
const posts = data?.pages.flatMap(page => page.posts) || []
透過 hasNextPage
去判斷是否沒有額外資料。
//..
<ul className="flex flex-col gap-[1rem] justify-center mt-5" >
{posts.map((post, index) => (
<li
key={post.id}
className="flex items-center justify-between cursor-pointer"
>
<label
htmlFor=""
className={`
text-2xl
${!!post.published && "line-through"}
`}
onClick={async () => {
await togglePost({ id: post.id, published: !post.published })
}}
>{post.title}</label>
<div className="flex items-center gap-[1rem]">
<AiFillDelete
color="red"
className="cursor-pointer"
size={20}
onClick={async () => {
await deletePost({ id: post.id })
}}
/>
<IoIosArchive
onClick={() => router.push(`/posts/${post.id}`)}
/>
</div>
</li>
))}
</ul>
{!hasNextPage && <p className="text-gray-500 py-2 text-center">no more data</p>}
..//
最後附上畫面
但我們要怎麼觸發 fetchNextPage
呢,這邊我使用 react-intersection-observer
去做,在做 infinite scroll
功能時候通常會搭配 intersection observer
,但這邊不多做說明可以簡單用套件去完成就好,想興趣的小夥伴可以選擇自己習慣的用法~
手用方很簡單只需要將 ref
放到你要監聽的 div
就好,inView
就會幫你判斷是否監聽的div
出現在畫面中。
import { useInView } from 'react-intersection-observer';
const { ref, inView, entry } = useInView({
/* Optional options */
threshold: 0,
});
然後放入 div
//..
<ul className="flex flex-col gap-[1rem] justify-center mt-5" >
{posts.map((post, index) => (
//..
))}
</ul>
{!hasNextPage && <p className="text-gray-500 py-2 text-center">no more data</p>}
<div ref={ref} className="invisible"></div>
..//
做後根據 inView
結果判斷是否要fetchNextPage
useEffect(() => {
if (!inView) return
fetchNextPage()
}, [inView])
這樣就完成摟~
大致上 infinite scroll
功能差不多這樣就完成了,剩下一些優化部分我們明天繼續介紹~
https://github.com/Danny101201/next_demo/tree/main
✅ 前端社群 :
https://lihi3.cc/kBe0Y